// All material copyright ESRI, All Rights Reserved, unless otherwise specified.
// See https://js.arcgis.com/4.6/esri/copyright.txt for details.
/* global importScripts: false */

/* REQUIRES esri/workers/scripts/helpers */

/* global merge, mixin */
(function(context) {
  /**
   * @typedef {Object} Envelope
   * @property {number} x - min x value
   * @property {number} y - min y value
   * @property {number} w - envelope width
   * @property {number} h - envelope height
   */
  /**
   * @typedef {Object} Point
   * @property {number} x - x coordinate
   * @property {number} y - y coordinate
   * @property {number=} z - optional z coordinate
   * @property {number=} m - optional m coordinate
   */
  /**
   * normalizes items for use in insert, search, & remove functions
   * @param  {(Array.<number>|Point|Envelope)} item - The item to normalize
   * @return {Point|Envelope)} {@link Point}s are returned for kdtree. {@link Envelope}s are returned for all other index systems
   */

  function prepareItem(item) {
    var norm;
    if (Array.isArray(item)) {
      if (item.length > 3) {
        //bbox
        norm = {
          x: item[0],
          y: item[1],
          w: Math.abs(item[0] - item[2]),
          h: Math.abs(item[1] - item[3])
        };
        if (this.system == "kdtree") {
          //use center point
          norm = {
            x: norm.x + (norm.w / 2),
            y: norm.y + (norm.h / 2)
          };
        }
      }
      else {
        //point
        norm = {
          x: item[0],
          y: item[1],
          attributes: item[3]
        };
        if (this.system != "kdtree") {
          norm.w = 0;
          norm.h = 0;
        }
      }
    }
    else if ("x" in item && "y" in item) {
      norm = item;
      if (this.system != "kdtree" && !("h" in item && "w" in item)) {
        //appears to be a point, convert to envelope
        norm.w = 0;
        norm.h = 0;
      }
    }
    return norm;
  }


  function Indexer(options) {
    options = options || {};
    var data = options.data;
    delete options.data;
    merge(options, this);
    if (data) {
      var ndxOpts = options.indexOptions || {};
      merge({layerId: options.layerId}, ndxOpts);
      this.index = this.create(data, ndxOpts);
    }
  }

  mixin(Indexer, /** @lends Indexer.prototype */ {
    /**
     * The indexing system to use. Choices are: "rtree", "quadtree", "kdtree". "kdtree" is only used for points.
     * @type {string}
     * @default 'rtree'
     */
    system: "rtree",
    /**
     * The index instance used
     * @type {Object}
     */
    index: null,
    /**
     * Creates the index and assigns it to the `index` property
     * @param  {Array.<(Array.<number>|Envelope|Point)>} data - the data to create the index with.
     * @param  {[type]} options [description]
     * @return {[type]}         [description]
     */
    create: function(data, options) {
      this.index = this["_create_" + this.system](data, options || this.indexOptions);
      return this.index;
    },
    /**
     * search the index
     * @param  {(Array.<number>|Envelope|{point: (Point|Array.<number>), count:number, distance:?number, filter:?function(Point):boolean})} criteria
     *  the search area and optional search options
     * @return {Array} An array of nodes matching the criteria
     */
    search: function(criteria, returnNode) {
      return this["_search_" + this.system](criteria, returnNode);
    },
    /**
     * insert item into index
     * @param  {(Array.<number>|Envelope|Point)} geom - the indexed area
     * @param  {*} data - information to store in the index (typically an id)
     * @return {Object} root node of index
     */
    insert: function(geom, data, layerId) {
      if (geom.geom) {
        data = geom.data;
        geom = geom.geom;
        delete geom.data;
      }
      return this["_insert_" + this.system](geom, data, layerId);
    },
    /**
     * remove item from index
     * @param  {(Array.<number>|Envelope|Point)} geom - the indexed area to remove from or clear
     * @param  {(boolean|*)} data - stored data which must match for a node to be removed. If `false` clear all nodes contained by the geom parameter
     * @return {Array} Array of removed nodes.
     */
    remove: function(geom, data) {
      if (geom.geom) {
        data = geom.data;
        geom = geom.geom;
        delete geom.data;
      }
      return this["_remove_" + this.system](geom, data);
    },
    /**
     * serializes the index
     * @return {string} JSON string representation of the index
     */
    serialize: function() {
      var index = this.index;
      if (index.toJSON !== Object.prototype.toJSON) {
        return index.toJSON();
      }
      else {
        return index.serialize && index.serialize();
      }
    },
    /**
     * create an index from serialized index
     * @param  {string} snapshot - JSON string representation of an index
     * @return {Object} created index
     */
    load: function(snapshot) {
      this.index = this["_load_" + this.system](snapshot);
      return this.index;
    },
    _indexLibraries: {
      kdtree: {
        ns: "kdTree",
        script: "libs/kdTree.js"
      },
      rtree: {
        ns: "RTree",
        script: "libs/rtree.js"
      },
      quadtree: {
        ns: "Quadtree",
        script: "libs/quadtree.js"
      }
    },
    _create_rtree: function(data, options) {
      if (!(this._indexLibraries.rtree.ns in context)) {
        importScripts(this._indexLibraries.rtree.script);
        /* global RTree, geomToBbox */
      }
      var tree = new RTree(options && options.width);
      var len = data.length;
      var item;
      while (len--) {
        item = data[len];
        item.id = (item.id != null) ? item.id : this.idField && item.attributes[this.idField];
        item.layerId = item.layerId || options.layerId;
        if (item.geometry) {
          tree.insert(prepareItem(geomToBbox(item.geometry)), item);
        }
        else {
          tree.insert(prepareItem(item), item);
        }
      }
      return tree;
    },
    _create_kdtree: function(data, options) {
      if (!(this._indexLibraries.kdtree.ns in context)) {
        importScripts(this._indexLibraries.kdtree.script);
        /* global kdTree */
      }
      options = options || {};
      /** @type {function(Point,Point): number} **/
      var dist = options.dist || function(j, k) {
        var xd = j.x - k.x; //a^2 + b^2 = c^2
        var yd = j.y - k.y;
        return Math.sqrt(xd * xd + yd * yd);
      };
      /** @type {Array.<string>} **/
      var dim = options.dimensions || ["x", "y"];
      var len = data.length;
      var pt, id;
      /** @type {Array.<Point>} **/
      var points = [];
      while (len--) {
        pt = data[len];
        id = (pt.id != null) ? pt.id : (this.idField && pt.attributes[this.idField]);
        if (pt.geometry) {
          pt.geometry = prepareItem(geomToBbox(pt.geometry));
          merge(pt.attributes, pt.geometry);
          pt.geometry.id = id;
          pt.geometry.layerId = options.layerId;
          points.push(pt.geometry);
        }
        else {
          pt = prepareItem(pt);
          pt.id = id;
          pt.layerId = options.layerId;
          points.push(pt);
        }
      }
      var tree = new kdTree(points, dist, dim); // eslint-disable-line new-cap
      return tree;
    },
    _create_quadtree: function(data, options) {
      throw "Not Implimented";
    },
    /**
     * search the kdtree index
     * @param  {{point: (Point|Array.<number>), count:?number, distance:?number, filter:?function(Point):boolean}} criteria
     *  the search area and options
     * @return {Array} An array of nodes matching the criteria
     * @private
     */
    _search_kdtree: function(criteria) {
      var c = criteria;
      if (c.geometry) {
        c.point = geomToBbox(c.geometry);
      }
      else if (c.point.geometry) {
        c.point = geomToBbox(c.point.geometry);
      }
      return this.index.nearest(prepareItem(c.point), c.count || 1, c.filter, c.distance);
    },
    /**
     * search the rtree index
     * @param  {(Array.<number>|Envelope)} criteria - the bounds to search
     * @return {Array} An array of indexed data matching the criteria
     * @private
     */
    _search_rtree: function(criteria, returnNode) {
      if (criteria.geometry) {
        //got a feature/graphic
        criteria = geomToBbox(criteria.geometry);
      }
      else if (criteria.xmin !== undefined && criteria.ymax !== undefined) {
        //got an esri.geometry.Extent convert to array
        var c = criteria;
        criteria = [c.xmin, c.ymin, c.xmax, c.ymax];
      }
      return this.index.search(prepareItem(criteria), returnNode === true);
    },
    _search_quadtree: function(criteria) {
      throw "Not Implimented";
    },
    _insert_kdtree: function(geom, data, layerId) {
      if (geom.geometry) {
        data = data || geom.attributes;
        data.id = (data.id != null) ? data.id : (this.idField && data[this.idField]);
        geom = geom.geometry;
      }
      geom = prepareItem(geom);
      geom.layerId = layerId;
      if (data) {
        if (typeof (data) == "object") {
          merge(data, geom);
        }
        else if (!Array.isArray(data)) {
          //either a string or number, use as id
          geom.id = data;
        }
      }
      this.index.insert(geom);
      return this.index.root;
    },
    _insert_rtree: function(geom, data, layerId) {
      var result;
      if (geom.geometry) {
        if (data == null) { //not using strict comparision. want to catch both null & undefined, but not 0
          data = geom;
        }
        geom = geomToBbox(geom.geometry);
      }

      if (typeof data == "string" || typeof data == "number") {
        data = {
          id: data,
          layerId: layerId
        };
      }
      else {
        data.id = (data.id != null) ? data.id : (this.idField && (data[this.idField] || data.attributes[this.idField]));
        data.layerId = layerId;
      }
      result = this.index.insert(prepareItem(geom), data);
      return result;
    },
    _insert_quadtree: function(geom, data) {
      throw "Not Implimented";
    },
    _remove_kdtree: function(geom, data) {
      geom = prepareItem(geom);
      if (data) {
        if (typeof (data) == "object") {
          merge(data, geom);
        }
        else if (!Array.isArray(data)) {
          //either a string or number, use as id
          geom.id = data;
        }
      }
      var node = this.index.remove(geom);
      return node && [node];
    },
    _remove_rtree: function(geom, data) {
      return this.index.remove(prepareItem(geom), data);
    },
    _remove_quadtree: function(geom, data) {
      throw "Not Implimented";
    },
    _load_rtree: function(snapshot) {
      if (!(this._indexLibraries.rtree.ns in context)) {
        importScripts(this._indexLibraries.rtree.script);
        /* global RTree, geomToBbox */
      }
      var tree = this.index || (this.index = new RTree());
      tree.deserialize(snapshot);
      return tree;
    },
    _load_kdtree: function(snapshot) {
      return this._create_kdtree(snapshot);
    }
  });

  context.Indexer = Indexer;
  return Indexer;

}(this));
